/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import {
    ConditionKind,
    EventType,
    type IPushRule,
    JoinRule,
    MatrixEvent,
    PendingEventOrdering,
    PushRuleActionName,
    Room,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";

import defaultDispatcher, { type MatrixDispatcher } from "../../../../src/dispatcher/dispatcher";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import SettingsStore, { type CallbackFn } from "../../../../src/settings/SettingsStore";
import { ListAlgorithm, SortAlgorithm } from "../../../../src/stores/room-list/algorithms/models";
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause } from "../../../../src/stores/room-list/models";
import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import { flushPromises, stubClient, upsertRoomStateEvents, mkRoom } from "../../../test-utils";
import { DEFAULT_PUSH_RULES, makePushRule } from "../../../test-utils/pushRules";

describe("RoomListStore", () => {
    const client = stubClient();
    const newRoomId = "!roomid:example.com";
    const roomNoPredecessorId = "!roomnopreid:example.com";
    const oldRoomId = "!oldroomid:example.com";
    const userId = "@user:example.com";
    const createWithPredecessor = new MatrixEvent({
        type: EventType.RoomCreate,
        sender: userId,
        room_id: newRoomId,
        content: {
            predecessor: { room_id: oldRoomId, event_id: "tombstone_event_id" },
        },
        event_id: "$create",
        state_key: "",
    });
    const createNoPredecessor = new MatrixEvent({
        type: EventType.RoomCreate,
        sender: userId,
        room_id: newRoomId,
        content: {},
        event_id: "$create",
        state_key: "",
    });
    const predecessor = new MatrixEvent({
        type: EventType.RoomPredecessor,
        sender: userId,
        room_id: newRoomId,
        content: {
            predecessor_room_id: oldRoomId,
            last_known_event_id: "tombstone_event_id",
        },
        event_id: "$pred",
        state_key: "",
    });
    const roomWithPredecessorEvent = new Room(newRoomId, client, userId, {});
    upsertRoomStateEvents(roomWithPredecessorEvent, [predecessor]);
    const roomWithCreatePredecessor = new Room(newRoomId, client, userId, {});
    upsertRoomStateEvents(roomWithCreatePredecessor, [createWithPredecessor]);
    const roomNoPredecessor = new Room(roomNoPredecessorId, client, userId, {
        pendingEventOrdering: PendingEventOrdering.Detached,
    });
    upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]);
    const oldRoom = new Room(oldRoomId, client, userId, {});
    const normalRoom = new Room("!normal:server.org", client, userId);
    client.getRoom = jest.fn().mockImplementation((roomId) => {
        switch (roomId) {
            case newRoomId:
                return roomWithCreatePredecessor;
            case oldRoomId:
                return oldRoom;
            case normalRoom.roomId:
                return normalRoom;
            default:
                return null;
        }
    });

    beforeAll(async () => {
        await (RoomListStore.instance as RoomListStoreClass).makeReady(client);
    });

    it.each(OrderedDefaultTagIDs)("defaults to importance ordering for %s=", (tagId) => {
        expect(RoomListStore.instance.getTagSorting(tagId)).toBe(SortAlgorithm.Recent);
    });

    it.each(OrderedDefaultTagIDs)("defaults to activity ordering for %s=", (tagId) => {
        expect(RoomListStore.instance.getListOrder(tagId)).toBe(ListAlgorithm.Natural);
    });

    function createStore(): { store: RoomListStoreClass; handleRoomUpdate: jest.Mock<any, any> } {
        const fakeDispatcher = { register: jest.fn() } as unknown as MatrixDispatcher;
        const store = new RoomListStoreClass(fakeDispatcher);
        // @ts-ignore accessing private member to set client
        store.readyStore.matrixClient = client;
        const handleRoomUpdate = jest.fn();
        // @ts-ignore accessing private member to mock it
        store.algorithm.handleRoomUpdate = handleRoomUpdate;

        return { store, handleRoomUpdate };
    }

    it("Removes old room if it finds a predecessor in the create event", () => {
        // Given a store we can spy on
        const { store, handleRoomUpdate } = createStore();

        // When we tell it we joined a new room that has an old room as
        // predecessor in the create event
        const payload = {
            oldMembership: KnownMembership.Invite,
            membership: KnownMembership.Join,
            room: roomWithCreatePredecessor,
        };
        store.onDispatchMyMembership(payload);

        // Then the old room is removed
        expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);

        // And the new room is added
        expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithCreatePredecessor, RoomUpdateCause.NewRoom);
    });

    it("Does not remove old room if there is no predecessor in the create event", () => {
        // Given a store we can spy on
        const { store, handleRoomUpdate } = createStore();

        // When we tell it we joined a new room with no predecessor
        const payload = {
            oldMembership: KnownMembership.Invite,
            membership: KnownMembership.Join,
            room: roomNoPredecessor,
        };
        store.onDispatchMyMembership(payload);

        // Then the new room is added
        expect(handleRoomUpdate).toHaveBeenCalledWith(roomNoPredecessor, RoomUpdateCause.NewRoom);
        // And no other updates happen
        expect(handleRoomUpdate).toHaveBeenCalledTimes(1);
    });

    it("Lists all rooms that the client says are visible", () => {
        // Given 3 rooms that are visible according to the client
        const room1 = new Room("!r1:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
        const room2 = new Room("!r2:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
        const room3 = new Room("!r3:e.com", client, userId, { pendingEventOrdering: PendingEventOrdering.Detached });
        room1.updateMyMembership(KnownMembership.Join);
        room2.updateMyMembership(KnownMembership.Join);
        room3.updateMyMembership(KnownMembership.Join);
        DMRoomMap.makeShared(client);
        const { store } = createStore();
        client.getVisibleRooms = jest.fn().mockReturnValue([room1, room2, room3]);

        // When we make the list of rooms
        store.regenerateAllLists({ trigger: false });

        // Then the list contains all 3
        expect(store.orderedLists).toMatchObject({
            "im.vector.fake.recent": [room1, room2, room3],
        });

        // We asked not to use MSC3946 when we asked the client for the visible rooms
        expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
        expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
    });

    it("Watches the feature flag setting", () => {
        jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("dyn_pred_ref");
        jest.spyOn(SettingsStore, "unwatchSetting");

        // When we create a store
        const { store } = createStore();

        // Then we watch the feature flag
        expect(SettingsStore.watchSetting).toHaveBeenCalledWith(
            "feature_dynamic_room_predecessors",
            null,
            expect.any(Function),
        );

        // And when we unmount it
        store.componentWillUnmount();

        // Then we unwatch it.
        expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("dyn_pred_ref");
    });

    it("Regenerates all lists when the feature flag is set", () => {
        // Given a store allowing us to spy on any use of SettingsStore
        let featureFlagValue = false;
        jest.spyOn(SettingsStore, "getValue").mockImplementation(() => featureFlagValue);

        let watchCallback: CallbackFn | undefined;
        jest.spyOn(SettingsStore, "watchSetting").mockImplementation(
            (_settingName: string, _roomId: string | null, callbackFn: CallbackFn) => {
                watchCallback = callbackFn;
                return "dyn_pred_ref";
            },
        );
        jest.spyOn(SettingsStore, "unwatchSetting");

        const { store } = createStore();
        client.getVisibleRooms = jest.fn().mockReturnValue([]);
        // Sanity: no calculation has happened yet
        expect(client.getVisibleRooms).toHaveBeenCalledTimes(0);

        // When we calculate for the first time
        store.regenerateAllLists({ trigger: false });

        // Then we use the current feature flag value (false)
        expect(client.getVisibleRooms).toHaveBeenCalledWith(false);
        expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);

        // But when we update the feature flag
        featureFlagValue = true;
        watchCallback!(
            "feature_dynamic_room_predecessors",
            "",
            SettingLevel.DEFAULT,
            featureFlagValue,
            featureFlagValue,
        );

        // Then we recalculate and passed the updated value (true)
        expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
        expect(client.getVisibleRooms).toHaveBeenCalledTimes(2);
    });

    describe("When feature_dynamic_room_predecessors = true", () => {
        beforeEach(() => {
            jest.spyOn(SettingsStore, "getValue").mockImplementation(
                (settingName) => settingName === "feature_dynamic_room_predecessors",
            );
        });

        afterEach(() => {
            jest.spyOn(SettingsStore, "getValue").mockReset();
        });

        it("Removes old room if it finds a predecessor in the m.predecessor event", () => {
            // Given a store we can spy on
            const { store, handleRoomUpdate } = createStore();

            // When we tell it we joined a new room that has an old room as
            // predecessor in the create event
            const payload = {
                oldMembership: KnownMembership.Invite,
                membership: KnownMembership.Join,
                room: roomWithPredecessorEvent,
            };
            store.onDispatchMyMembership(payload);

            // Then the old room is removed
            expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);

            // And the new room is added
            expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithPredecessorEvent, RoomUpdateCause.NewRoom);
        });

        it("Passes the feature flag on to the client when asking for visible rooms", () => {
            // Given a store that we can ask for a room list
            DMRoomMap.makeShared(client);
            const { store } = createStore();
            client.getVisibleRooms = jest.fn().mockReturnValue([]);

            // When we make the list of rooms
            store.regenerateAllLists({ trigger: false });

            // We asked to use MSC3946 when we asked the client for the visible rooms
            expect(client.getVisibleRooms).toHaveBeenCalledWith(true);
            expect(client.getVisibleRooms).toHaveBeenCalledTimes(1);
        });
    });

    describe("room updates", () => {
        const makeStore = async () => {
            const store = new RoomListStoreClass(defaultDispatcher);
            await store.start();
            return store;
        };

        describe("push rules updates", () => {
            const makePushRulesEvent = (overrideRules: IPushRule[] = []): MatrixEvent => {
                return new MatrixEvent({
                    type: EventType.PushRules,
                    content: {
                        global: {
                            ...DEFAULT_PUSH_RULES.global,
                            override: overrideRules,
                        },
                    },
                });
            };

            it("triggers a room update when room mutes have changed", async () => {
                const rule = makePushRule(normalRoom.roomId, {
                    actions: [PushRuleActionName.DontNotify],
                    conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }],
                });
                const event = makePushRulesEvent([rule]);
                const previousEvent = makePushRulesEvent();

                const store = await makeStore();
                // @ts-ignore private property alg
                const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined);
                // @ts-ignore cheat and call protected fn
                store.onAction({ action: "MatrixActions.accountData", event, previousEvent });
                await flushPromises();

                expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange);
            });

            it("handles when a muted room is unknown by the room list", async () => {
                const rule = makePushRule(normalRoom.roomId, {
                    actions: [PushRuleActionName.DontNotify],
                    conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: normalRoom.roomId }],
                });
                const unknownRoomRule = makePushRule("!unknown:server.org", {
                    conditions: [{ kind: ConditionKind.EventMatch, key: "room_id", pattern: "!unknown:server.org" }],
                });
                const event = makePushRulesEvent([unknownRoomRule, rule]);
                const previousEvent = makePushRulesEvent();

                const store = await makeStore();
                // @ts-ignore private property alg
                const algorithmSpy = jest.spyOn(store.algorithm, "handleRoomUpdate").mockReturnValue(undefined);

                // @ts-ignore cheat and call protected fn
                store.onAction({ action: "MatrixActions.accountData", event, previousEvent });
                await flushPromises();

                // only one call to update made for normalRoom
                expect(algorithmSpy).toHaveBeenCalledTimes(1);
                expect(algorithmSpy).toHaveBeenCalledWith(normalRoom, RoomUpdateCause.PossibleMuteChange);
            });
        });
    });

    describe("Correctly tags rooms", () => {
        it("renders Public and Knock rooms in Conferences section", () => {
            const videoRoomPrivate = "!videoRoomPrivate_server";
            const videoRoomPublic = "!videoRoomPublic_server";
            const videoRoomKnock = "!videoRoomKnock_server";

            const rooms: Room[] = [];
            mkRoom(client, videoRoomPrivate, rooms);
            mkRoom(client, videoRoomPublic, rooms);
            mkRoom(client, videoRoomKnock, rooms);

            mocked(client).getRoom.mockImplementation((roomId) => rooms.find((room) => room.roomId === roomId) || null);
            mocked(client).getRooms.mockImplementation(() => rooms);

            const videoRoomKnockRoom = client.getRoom(videoRoomKnock);
            (videoRoomKnockRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Knock);

            const videoRoomPrivateRoom = client.getRoom(videoRoomPrivate);
            (videoRoomPrivateRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Invite);

            const videoRoomPublicRoom = client.getRoom(videoRoomPublic);
            (videoRoomPublicRoom!.getJoinRule as jest.Mock).mockReturnValue(JoinRule.Public);

            [videoRoomPrivateRoom, videoRoomPublicRoom, videoRoomKnockRoom].forEach((room) => {
                (room!.isCallRoom as jest.Mock).mockReturnValue(true);
            });

            expect(
                RoomListStore.instance
                    .getTagsForRoom(client.getRoom(videoRoomPublic)!)
                    .includes(DefaultTagID.Conference),
            ).toBeTruthy();
            expect(
                RoomListStore.instance
                    .getTagsForRoom(client.getRoom(videoRoomKnock)!)
                    .includes(DefaultTagID.Conference),
            ).toBeTruthy();
            expect(
                RoomListStore.instance
                    .getTagsForRoom(client.getRoom(videoRoomPrivate)!)
                    .includes(DefaultTagID.Conference),
            ).toBeFalsy();
        });
    });
});
